因為公司同時在維護自己開發的系統,我們最近導入了監控軟體,追蹤建置在各縣市主機上的自家系統回應時間,本來只是想確認大家都正常運作。結果意外挖出一個「案外案」:同一套程式碼、同樣的後端動作,別的縣市 0.2–0.5 秒就回應,某一個縣市卻要 2 秒以上,慢了將近十倍。
這篇要講的,是怎麼把這 2 秒從「感覺很慢」一路拆到最後發現:兩台完全不同的主機,栽在同一招上,只是各自被發現的過程、影響的範圍都不一樣。過程中 AI 前前後後翻案了好幾次,而且每一次翻案的關鍵證據,都是我在現場多做的一個測試給出來的。
AI 一開始的猜測是「這個縣市的首頁會多轉址一次,別的縣市已經是最終頁面」,聽起來很合理。但我補了另外幾個縣市的對照組之後,這個猜測直接死掉——不只一個縣市的網址結構、轉址次數完全一樣,回應時間卻差了十倍。 轉址深度不是變因。
既然程式碼相同,變因就被鎖定在基礎設施層。做法是「由內往外逐層測,每加一層算一次差值」:
減法算到這裡,結論一度收斂成「外部量到 2.2 秒,內網只要 0.24 秒,中間 1.96 秒是被 Nginx 之外的某個外圍設備吃掉的」。準備要就此結案,寫信去跟這個縣市的資訊單位反映,是他們前端的某個網路設備拖慢了正式環境。當下打算就這樣結案,發信去跟這個縣市的資訊單位反映,是他們那邊的網路設備拖慢了正式環境。
我自己拿開發機從外部直接 curl 同一個網址,結果只要 131 毫秒。這個結果把前面的結論整個推翻——如果外部連線本來就只要 131 毫秒,那 Uptime Kuma 量到的 2.2 秒,問題搞不好是監控軟體自己量錯了,不是這個縣市的系統真的在慢。 原本準備要寄給這個縣市資訊單位、指控是他們外圍設備拖慢系統的信,前提可能整個是錯的。AI 當場承認:「這個發現把整件事翻過來了……那份報告先別寄出去。」
但我沒有就此收手,緊接著做了一件事:在 curl 加上跟隨轉址的參數,重新測一次。
「跟隨就明顯慢下來了。」
數字顯示:不跟隨轉址只要 131 毫秒,跟隨之後暴增到 2188 毫秒——跟 Uptime Kuma 量到的數字幾乎完全吻合。原來慢的不是「連到首頁」這一步,是轉址之後那個真正的登入頁。監控軟體本身是無辜的,這個縣市真的有問題,只是問題藏在下一跳,不是第一跳。這一來一往的翻案,靠的完全是我自己多加了一個參數重新驗證,不是靠 AI 想到的。
回頭用跟隨轉址重新分層量測:AP 本機約 100 毫秒、AP 到 Nginx 內網約 200 毫秒,都乾淨。真正的落差還是卡在「外部量到的 2 秒」跟「內網量到的 0.2 秒」之間,範圍又縮小了,但真凶還沒現形。
我做了一個實驗:連續打三次、間隔一兩分鐘再打、再連續打——把結果按時間攤開。規律很乾淨:每一輪的第一次都是 2 秒起跳,緊接著的後續請求全部降到 0.2 秒。 AI 判讀為「冷啟動」現象。
中途有一筆內網測試的數據看起來不符合這個規律(明明是熱的狀態卻跑出 1.9 秒),一度讓 AI 想把冷啟動理論打掉。但我判斷「這種孤立的例外,比隨機抖動的假設更牽強」,AI 也認同這個判斷,冷啟動理論保留下來,繼續往下查「閒置之後,重新建立的到底是什麼」。
真正釘死問題出在哪一層的,是 Nginx 自己的 access log——多加了 upstream_header_time 這個欄位後,某一筆記錄顯示「等 AP 吐出第一個回應標頭,Nginx 自己等了 2.358 秒」,跟同一時刻外部量到的 2.538 秒幾乎對上。這代表慢的不是外圍設備,是 AP 這一側,而且是在等某個東西回應的階段。
中途還冒出一個「會不會是 X-Forwarded-For 造成的」的猜測,但被一組對照數據當場否決——不帶這個標頭的請求,一樣是冷 2 秒、熱 0.2 秒,跟有沒有帶標頭完全無關。
追到資料庫連線層,找到了 Sequelize 的連線池設定:
pool: {
max: 130,
min: 0, // 閒置後連線池歸零
idle: 10000, // 閒置 10 秒就回收
}
min: 0 加上 idle: 10000,代表閒置超過 10 秒,連線全部被回收,下一個請求進來得從零建立新連線。但這個設定六個縣市都一樣,只有這個縣市會爆——這代表 min: 0 本身不是病因,它只是「暴露器」:真正的問題是這個縣市重建一條資料庫連線要花 2 秒,其他縣市只要幾十毫秒。
這裡有個小插曲:AI 一度擔心把 min 調高會讓六個縣市加總的常駐連線數太多,我指出這個算法錯了——「你把『6 個縣市 × min:10』加總起來算,那是錯的,各縣市各自獨立部署、連各自的資料庫,這個乘法沒有意義」。AI 立刻認錯收回。順手一測,還意外揪出另一個原本沒被注意到的風險:連線池上限 max: 130 比資料庫自己允許的連線數上限還高,一旦真的衝到滿載,不會只是變慢,是直接連不上。
先把 min 調成 10 當第一輪處理:多數請求確實降到 0.5 秒以內,跟其他縣市站上同一水準。但沒有斷根——久久還是會撞回一次兩秒多的冷啟動,只是頻率變低了。這代表調高 min 只是降低「重建連線」這件事發生的頻率,沒有解決「重建連線這個動作本身為什麼這麼貴」。
真正把貴的原因挖出來,靠的是為了準備跟資訊單位正式對測、需要更硬的證據,繼續追問「重建一條連線為什麼要 469 毫秒,這正常嗎」。追下去發現:主機上的防毒軟體有「行為監控」機制,對每一個新啟動的行程做攔截掃描,而 PostgreSQL 在 Windows 上是「每條連線一個新行程」的架構——連線池每回收重建一次,就是一次全新的行程啟動,就再挨一次防毒的掃描稅。min:0 只是把這個機制觸發得特別頻繁,真正的病根是防毒即時掃描本身。
驗證這個結論的方式很乾脆:把 PG 主機加上防毒排除規則,同時把 min 改回 0(也就是刻意恢復高頻率的連線重建)。如果病根真的是防毒掃描,排除規則生效後,就算恢復 min:0、每次都重新建立連線,也不該再慢。結果確實如此——改完之後穩定維持在 0.5 秒內,沒有再出現過冷啟動。 這個對照實驗,比單純「調高 min 讓症狀消失」更有說服力:它證明了問題不在連線池設定本身,而在防毒攔截了行程啟動這個動作。
追查防毒問題時,我追問了一句「同樣的機制會不會也影響 Oracle」。換到 Oracle 主機測同樣性質的指令(sqlplus -V,只印版本號、不連線、不查詢、不碰網路),結果是 5,133 毫秒,正常值應該在 10-20 毫秒等級,慢了 300 多倍。重測一次(同一支程式再啟動),降到 291 毫秒——跟 PG 端一樣的「第一次被掃描很貴,之後快很多」模式。
但這裡有一個關鍵差異:Oracle 的資料庫連線本身,走的是 thread-per-connection 架構,建立一條連線不需要 spawn 新行程,所以不會踩到這個防毒稅——會被課稅的只有像 sqlplus.exe 這種獨立執行檔被啟動的當下,跟正式系統實際查詢資料庫的路徑完全無關。這代表 Oracle 這邊的防毒問題,從頭到尾都沒有參與到這個縣市系統變慢的症狀,是額外查出來的另一個隱患。
它會影響到的,是我們自己日常用 sqlplus 或其他工具手動維護、備份、監控這台主機時,每次啟動都要多等好幾秒——不影響系統對外提供服務,只影響維護作業的體感,優先度自然排在使用者可見的問題之後。這也是這篇故事誠實的收尾:不是每個查出來的問題都會在故事結束前修完,找到原因、排定優先序,本身就是一種負責任的處理方式。
這兩個病灶,是同一種病理機制(防毒即時掃描對新行程建立課稅)分別在兩台主機上各自發作,不是巧合撞出兩個長得像的症狀。 差別在於:PG 端因為連線池的設計,把這個成本重複觸發、直接反映到使用者體感上,逼得非修不可;Oracle 端因為架構不同(thread 而非 process),只在少數操作上現形,影響範圍小到可以先擱著。
回頭看整個過程,AI 提供的是扎實的方法論——怎麼用分層測試把變因一個個隔離、怎麼設計「不碰網路不碰查詢」的純淨測試把防毒的影響單獨切出來。但每一次關鍵的轉折,靠的都是我在現場多做的那一個動作:多測一個對照縣市、多加一個跟隨轉址的參數、多設計一組連續打跟間隔打的對照實驗、多問一句「Oracle 那邊會不會也中」。AI 負責把每一次新數據的意義講清楚,但決定「下一步要多測什麼」的,幾乎都是我自己的直覺跟不甘心。
最後這份報告要拿去跟資訊單位對測,AI 提醒了一個很實際的重點:證據的強弱有層次。「連線變慢」可以被對方用「網路」「負載」隨便帶過去,但「一個完全不碰資料庫、只是印版本號的指令要五秒」,對方無從辯駁——因為那個數字量的不是系統,是那台主機本身有沒有把資料庫排除在防毒掃描範圍之外。挑對最硬的證據放在最前面,是這份報告能不能站得住腳的關鍵。
明天要看的是一段沒有 bug、沒有崩潰的協作——一次單純的需求討論,AI 沒有犯任何錯,但它主動查證的習慣,反而戳破了我自己過度樂觀的一個假設。